iT邦幫忙

2025 iThome 鐵人賽

DAY 9
0
Modern Web

30 天 Rails 新手村:從工作專案學會 Ruby on Rails系列 第 9

Day 8: ActiveRecord 進階關聯與查詢優化 - 用程式碼表達業務關係的藝術

  • 分享至 

  • xImage
  •  

從 ORM 的選擇說起

如果你來自 Node.js 的世界,你可能在 Sequelize、TypeORM 或 Prisma 之間做過選擇。每次建立關聯時,你需要明確定義外鍵、選擇聯結策略、手動處理關聯載入。在 Spring Boot 中使用 Hibernate,你會習慣於 @OneToMany@ManyToMany 這些註解,以及 FetchType.LAZYFetchType.EAGER 的權衡。如果你用過 SQLAlchemy,你知道 relationship() 的強大,也體會過 backrefback_populates 的微妙差異。

今天我們要探討的是 Rails 如何用完全不同的思維解決相同的問題。ActiveRecord 不只是一個 ORM,它是一種用程式碼表達業務關係的語言。當你寫下 has_many :students, through: :enrollments 時,你不只是在定義資料庫關聯,更是在描述業務領域的真實關係。

我們今天的學習直接指向 LMS 系統的核心挑戰:如何優雅地表達課程、學生、作業、成績這些複雜實體之間的關係?如何在保持程式碼可讀性的同時,避免效能陷阱?這些都是我們要解答的問題。

重新理解關聯:從資料到業務

Rails 關聯的設計哲學

Rails 的關聯設計有三個核心理念,理解它們能讓你寫出更好的程式碼:

理念一:關聯是雙向的契約

# 當你定義一個關聯
class Course < ApplicationRecord
  has_many :enrollments
end

# Rails 自動建立了反向關聯的可能性
class Enrollment < ApplicationRecord
  belongs_to :course  # 這是契約的另一端
end

與 Sequelize 需要手動定義雙向關聯不同,Rails 透過命名約定自動推斷關係。但這不是魔法,而是基於一個深刻的洞察:業務關係本質上是雙向的。一個課程有多個註冊,一個註冊必然屬於某個課程。

理念二:關聯物件是活的

# 在其他框架中,關聯通常返回純資料
# 在 Rails 中,關聯返回的是 ActiveRecord::Relation

course = Course.find(1)
enrollments = course.enrollments  # 這不是陣列,是可查詢物件

# 你可以繼續添加條件
active_enrollments = enrollments.where(status: 'active')
                                .includes(:user)
                                .order(created_at: :desc)

這種設計讓你能夠漸進式地構建查詢,而不是一次性載入所有資料。

理念三:中間表是一等公民

這是 Rails 最重要的洞察之一。在許多 ORM 中,多對多關聯的中間表只是技術細節。Rails 認為中間表往往代表重要的業務概念:

# 不好的設計:把中間表當作技術細節
class Course < ApplicationRecord
  has_and_belongs_to_many :users  # 使用隱含的 courses_users 表
end

# 好的設計:承認 Enrollment 的業務價值
class Course < ApplicationRecord
  has_many :enrollments
  has_many :students, through: :enrollments, source: :user
  
  # Enrollment 不只是關聯,它記錄了:
  # - 註冊時間
  # - 學習進度
  # - 成績
  # - 完成狀態
  # - 付費資訊
end

has_many :through 的深度運用

基礎:理解 through 的本質

has_many :through 不只是為了建立多對多關係,它是一種表達「透過某個業務實體產生關聯」的方式:

# LMS 系統中的核心關聯設計
class User < ApplicationRecord
  # 作為學生
  has_many :enrollments
  has_many :enrolled_courses, through: :enrollments, source: :course
  
  # 作為講師
  has_many :teaching_courses, class_name: 'Course', foreign_key: 'instructor_id'
  
  # 作為助教
  has_many :assistantships
  has_many :assisting_courses, through: :assistantships, source: :course
  
  # 複雜查詢:找出某個學生的所有講師
  def instructors
    User.joins(:teaching_courses)
        .where(courses: { id: enrolled_courses.pluck(:id) })
        .distinct
  end
end

class Course < ApplicationRecord
  belongs_to :instructor, class_name: 'User'
  has_many :enrollments
  has_many :students, through: :enrollments, source: :user
  
  # 透過章節找到所有的課時
  has_many :chapters
  has_many :lessons, through: :chapters
  
  # 透過作業找到所有提交
  has_many :assignments
  has_many :submissions, through: :assignments
  
  # 計算課程的平均完成率
  def average_completion_rate
    enrollments.active.average(:progress_percentage) || 0
  end
end

進階:多層級的 through 關聯

Rails 支援多層級的 through 關聯,這在表達複雜業務關係時特別有用:

class Course < ApplicationRecord
  has_many :chapters
  has_many :lessons, through: :chapters
  has_many :lesson_completions, through: :lessons
  
  # 找出完成特定課程所有課時的學生
  def students_completed_all_lessons
    total_lessons = lessons.count
    
    students.joins(:lesson_completions)
            .where(lesson_completions: { lesson_id: lessons.pluck(:id) })
            .group('users.id')
            .having('COUNT(DISTINCT lesson_completions.lesson_id) = ?', total_lessons)
  end
end

class Chapter < ApplicationRecord
  belongs_to :course
  has_many :lessons, -> { order(:position) }
  
  # 使用 scope 來組織複雜查詢
  scope :published, -> { where(published: true) }
  scope :with_video_lessons, -> { joins(:lessons).where(lessons: { content_type: 'video' }).distinct }
end

條件關聯與動態關聯

有時我們需要根據條件建立不同的關聯:

class Course < ApplicationRecord
  # 基本關聯
  has_many :enrollments
  
  # 條件關聯:只載入活躍的註冊
  has_many :active_enrollments, -> { where(status: 'active') }, 
           class_name: 'Enrollment'
  
  # 帶參數的關聯
  has_many :recent_enrollments, ->(days = 7) { 
    where('enrollments.created_at > ?', days.days.ago) 
  }, class_name: 'Enrollment'
  
  # 複雜的條件關聯
  has_many :top_students,
           -> { 
             joins(:lesson_completions)
             .group('users.id')
             .having('COUNT(lesson_completions.id) > ?', 10)
             .order('COUNT(lesson_completions.id) DESC')
             .limit(10)
           },
           through: :enrollments,
           source: :user
end

多型關聯:終極的靈活性

理解多型關聯的使用場景

多型關聯讓一個模型可以屬於多個不同類型的父模型。在 LMS 系統中,這特別適合用於評論、標籤、通知等跨實體的功能:

# 評論系統:可以評論課程、課時、作業
class Comment < ApplicationRecord
  belongs_to :commentable, polymorphic: true
  belongs_to :user
  
  # 巢狀評論
  belongs_to :parent, class_name: 'Comment', optional: true
  has_many :replies, class_name: 'Comment', foreign_key: 'parent_id'
  
  # 智慧的 scope 設計
  scope :root_comments, -> { where(parent_id: nil) }
  scope :recent, -> { order(created_at: :desc) }
  
  # 取得評論的上下文標題
  def context_title
    case commentable_type
    when 'Course'
      "課程:#{commentable.title}"
    when 'Lesson'
      "課時:#{commentable.title}(#{commentable.chapter.course.title})"
    when 'Assignment'
      "作業:#{commentable.title}"
    end
  end
end

# 使用多型關聯的模型
class Course < ApplicationRecord
  has_many :comments, as: :commentable, dependent: :destroy
  
  # 取得所有相關評論(包含課時和作業的評論)
  def all_related_comments
    course_comments = comments
    lesson_comments = Comment.where(commentable: lessons)
    assignment_comments = Comment.where(commentable: assignments)
    
    Comment.where(id: course_comments.or(lesson_comments).or(assignment_comments))
  end
end

class Lesson < ApplicationRecord
  has_many :comments, as: :commentable, dependent: :destroy
  
  # 包含回覆的評論統計
  def comments_count_with_replies
    comments.includes(:replies).sum { |c| 1 + c.replies.count }
  end
end

多型關聯的效能優化

多型關聯的一個挑戰是無法使用外鍵約束,而且預載入較複雜。以下是優化策略:

# 問題:N+1 查詢
comments = Comment.recent.limit(20)
comments.each do |comment|
  puts comment.commentable.title  # 每個都會查詢資料庫
end

# 解決方案 1:手動預載入
comments = Comment.recent.limit(20).includes(:commentable)
# 但這會載入所有類型的 commentable,可能浪費記憶體

# 解決方案 2:分組預載入
class Comment < ApplicationRecord
  # 自訂預載入方法
  def self.with_commentables
    comments = all.to_a
    
    # 按類型分組
    grouped = comments.group_by(&:commentable_type)
    
    # 分別載入每種類型
    grouped.each do |type, comments_group|
      ids = comments_group.map(&:commentable_id)
      
      # 使用 where(id: ids) 一次載入所有記錄
      records = type.constantize.where(id: ids).index_by(&:id)
      
      # 手動設定關聯,避免額外查詢
      comments_group.each do |comment|
        comment.association(:commentable).target = records[comment.commentable_id]
      end
    end
    
    comments
  end
end

# 使用優化後的查詢
Comment.recent.limit(20).with_commentables.each do |comment|
  puts comment.commentable.title  # 不會產生額外查詢
end

N+1 查詢:識別、理解與解決

深入理解 N+1 的本質

N+1 查詢是 ORM 最常見的效能問題。理解它的本質能幫助我們寫出更好的查詢:

# 典型的 N+1 問題
def display_course_list
  courses = Course.all  # 1 個查詢
  
  courses.each do |course|
    puts course.instructor.name  # N 個查詢(每個課程一個)
    puts course.enrollments.count  # 又 N 個查詢
    puts course.chapters.count  # 再 N 個查詢
  end
  # 總共:1 + 3N 個查詢
end

# 解決方案:使用 includes
def optimized_course_list
  courses = Course.includes(:instructor, :enrollments, :chapters)
  # 產生 4 個查詢(比 1 + 3N 好太多)
  
  courses.each do |course|
    puts course.instructor.name
    puts course.enrollments.size  # 注意:用 size 而非 count
    puts course.chapters.size
  end
end

includes vs preload vs eager_load 的細微差異

這三個方法都能解決 N+1,但實作方式不同:

# includes:智慧選擇策略
# Rails 會根據查詢條件自動選擇使用 preload 或 eager_load
Course.includes(:enrollments).where('enrollments.status = ?', 'active')
# 因為 WHERE 條件涉及關聯表,Rails 會使用 LEFT OUTER JOIN

Course.includes(:enrollments).to_a
# 沒有涉及關聯表的條件,Rails 會使用兩個獨立查詢

# preload:總是使用獨立查詢
Course.preload(:enrollments, :instructor)
# 產生三個查詢:
# SELECT * FROM courses
# SELECT * FROM enrollments WHERE course_id IN (...)
# SELECT * FROM users WHERE id IN (...)

# eager_load:總是使用 LEFT OUTER JOIN
Course.eager_load(:enrollments, :instructor)
# 產生一個複雜的 JOIN 查詢
# 優點:單一查詢
# 缺點:可能返回大量重複資料

# joins:只做 INNER JOIN,不預載入
Course.joins(:enrollments).where(enrollments: { status: 'active' })
# 用於過濾,但不會預載入關聯資料

實戰:LMS 的複雜查詢優化

讓我們看一個真實的 LMS 查詢優化案例:

class CourseService
  # 糟糕的實作:大量 N+1
  def self.dashboard_data_bad(user)
    courses = user.enrolled_courses
    
    courses.map do |course|
      {
        title: course.title,
        instructor: course.instructor.name,
        progress: calculate_progress(user, course),
        upcoming_lessons: course.lessons
                                .where('scheduled_at > ?', Time.current)
                                .limit(3),
        recent_announcements: course.announcements
                                   .where('created_at > ?', 7.days.ago)
                                   .count
      }
    end
  end
  
  # 優化版本:精心設計的預載入
  def self.dashboard_data_optimized(user)
    # 一次載入所有需要的資料
    courses = user.enrolled_courses
                  .includes(
                    :instructor,
                    :lessons,
                    :announcements,
                    chapters: :lessons
                  )
                  .where(status: 'active')
    
    # 批次計算進度
    progress_data = calculate_bulk_progress(user, courses.pluck(:id))
    
    courses.map do |course|
      {
        title: course.title,
        instructor: course.instructor.name,
        progress: progress_data[course.id],
        upcoming_lessons: course.lessons
                                .select { |l| l.scheduled_at > Time.current }
                                .first(3),
        recent_announcements: course.announcements
                                   .select { |a| a.created_at > 7.days.ago }
                                   .size
      }
    end
  end
  
  private
  
  def self.calculate_bulk_progress(user, course_ids)
    # 使用單一查詢計算所有課程的進度
    LessonCompletion
      .joins(lesson: { chapter: :course })
      .where(user: user, 'courses.id': course_ids)
      .group('courses.id')
      .count
      .transform_values { |count| (count.to_f / total_lessons * 100).round(2) }
  end
end

建構複雜查詢的藝術

使用 Arel 進行進階查詢

當 ActiveRecord 的 DSL 不夠用時,Arel 提供了更強大的查詢能力:

class Course < ApplicationRecord
  # 找出熱門課程:註冊人數多且完成率高
  def self.popular_with_high_completion
    courses = arel_table
    enrollments = Enrollment.arel_table
    
    # 建構子查詢
    enrollment_counts = enrollments
      .project(enrollments[:course_id], enrollments[:id].count.as('enrollment_count'))
      .where(enrollments[:status].eq('active'))
      .group(enrollments[:course_id])
      .as('enrollment_stats')
    
    # 主查詢
    joins(
      courses.join(enrollment_counts, Arel::Nodes::OuterJoin)
             .on(courses[:id].eq(enrollment_counts[:course_id]))
             .join_sources
    )
    .where('enrollment_stats.enrollment_count > ?', 50)
    .where('completion_rate > ?', 0.7)
    .order('enrollment_stats.enrollment_count DESC')
  end
end

使用 SQL 片段與安全性

有時我們需要使用原始 SQL,但要注意安全性:

class CourseSearchService
  def self.advanced_search(params)
    courses = Course.all
    
    # 安全的參數化查詢
    if params[:keyword].present?
      keyword = "%#{sanitize_sql_like(params[:keyword])}%"
      courses = courses.where(
        "title ILIKE :keyword OR description ILIKE :keyword",
        keyword: keyword
      )
    end
    
    # 使用 Arel 處理複雜條件
    if params[:duration_range].present?
      range = params[:duration_range]
      courses = courses.where(duration_hours: range[:min]..range[:max])
    end
    
    # 子查詢:找出有特定標籤的課程
    if params[:tags].present?
      tag_ids = params[:tags]
      courses = courses.where(
        "EXISTS (
          SELECT 1 FROM course_tags 
          WHERE course_tags.course_id = courses.id 
          AND course_tags.tag_id IN (?)
        )",
        tag_ids
      )
    end
    
    # 複雜排序
    case params[:sort_by]
    when 'popularity'
      courses.left_joins(:enrollments)
             .group('courses.id')
             .order('COUNT(enrollments.id) DESC')
    when 'rating'
      courses.left_joins(:reviews)
             .group('courses.id')
             .order('AVG(reviews.rating) DESC NULLS LAST')
    else
      courses.order(created_at: :desc)
    end
  end
  
  private
  
  def self.sanitize_sql_like(string)
    string.gsub(/[%_\\]/, '\\\\\\&')
  end
end

效能監控與優化實踐

使用 Bullet 偵測 N+1

Bullet 是偵測 N+1 查詢的利器:

# Gemfile
group :development do
  gem 'bullet'
end

# config/environments/development.rb
Rails.application.configure do
  config.after_initialize do
    Bullet.enable = true
    Bullet.alert = true
    Bullet.bullet_logger = true
    Bullet.console = true
    Bullet.rails_logger = true
    Bullet.add_footer = true
  end
end

# 實際使用時,Bullet 會提醒你:
# GET /courses
# USE eager loading detected
#   Course => [:instructor]
#   Add to your query: .includes([:instructor])

查詢分析與索引優化

# 使用 explain 分析查詢計劃
Course.joins(:enrollments)
      .where(enrollments: { status: 'active' })
      .group('courses.id')
      .having('COUNT(enrollments.id) > ?', 10)
      .explain

# 建立複合索引
class AddIndexesToOptimizeQueries < ActiveRecord::Migration[7.1]
  def change
    # 複合索引:順序很重要
    add_index :enrollments, [:course_id, :status, :created_at]
    add_index :lessons, [:chapter_id, :position]
    
    # 部分索引:只索引需要的資料
    add_index :enrollments, :user_id, where: "status = 'active'"
    
    # 表達式索引(PostgreSQL)
    execute <<-SQL
      CREATE INDEX index_courses_on_lower_title 
      ON courses (LOWER(title));
    SQL
  end
end

實戰練習:建構 LMS 的查詢層

基礎練習:關聯設計(30 分鐘)

練習目標: 設計一個簡化版的 LMS 資料模型,學習如何用 Rails 的關聯表達複雜的業務關係。這個練習會幫助你理解為什麼中間表在 Rails 中如此重要,以及如何利用多型關聯實現靈活的系統設計。

需求說明:

  1. 使用者可以是學生或講師(使用 STI 或角色系統)
  2. 課程包含多個章節,章節包含多個課時
  3. 學生可以提交作業,講師可以批改
  4. 實作評論系統(可評論課程、課時、作業)

完整解答與說明:

# app/models/user.rb
class User < ApplicationRecord
  # 使用角色系統而非 STI,因為一個使用者可能同時是學生和講師
  # 這種設計提供了更大的靈活性
  
  # 作為學生的關聯
  has_many :enrollments, dependent: :destroy
  has_many :enrolled_courses, through: :enrollments, source: :course
  
  # 作為講師的關聯
  # 注意這裡使用 class_name 和 foreign_key 來明確指定關聯
  has_many :teaching_courses, class_name: 'Course', foreign_key: 'instructor_id'
  
  # 作業提交
  has_many :submissions, dependent: :destroy
  
  # 評論
  has_many :comments, dependent: :destroy
  
  # 輔助方法:檢查是否為特定課程的講師
  def instructor_of?(course)
    teaching_courses.include?(course)
  end
  
  # 輔助方法:檢查是否為特定課程的學生
  def student_of?(course)
    enrolled_courses.include?(course)
  end
end

# app/models/course.rb
class Course < ApplicationRecord
  # 基本關聯
  belongs_to :instructor, class_name: 'User'
  
  # 學生關聯:透過 enrollments 中間表
  # 使用 dependent: :destroy 確保刪除課程時清理相關資料
  has_many :enrollments, dependent: :destroy
  has_many :students, through: :enrollments, source: :user
  
  # 課程結構:章節和課時
  # 使用 -> { order(:position) } lambda 確保章節按順序載入
  has_many :chapters, -> { order(:position) }, dependent: :destroy
  has_many :lessons, through: :chapters
  
  # 作業系統
  has_many :assignments, dependent: :destroy
  has_many :submissions, through: :assignments
  
  # 多型評論
  has_many :comments, as: :commentable, dependent: :destroy
  
  # 驗證
  validates :title, presence: true
  validates :instructor, presence: true
  
  # Scope 用於常見查詢
  scope :published, -> { where(published: true) }
  scope :by_instructor, ->(user) { where(instructor: user) }
  
  # 商業邏輯方法
  def enrollment_count
    enrollments.count
  end
  
  def average_progress
    enrollments.average(:progress_percentage) || 0
  end
end

# app/models/enrollment.rb
class Enrollment < ApplicationRecord
  # Enrollment 是一個重要的業務實體,不只是關聯表
  # 它記錄了學生的學習狀態和進度
  
  belongs_to :user
  belongs_to :course
  
  # 追蹤學習進度的欄位
  # progress_percentage: 0-100 的整數,表示完成百分比
  # status: enrolled, active, completed, dropped
  # grade: 最終成績
  
  # 驗證:確保同一使用者不能重複註冊同一課程
  validates :user_id, uniqueness: { scope: :course_id, 
    message: "已經註冊過這門課程" }
  
  # 狀態管理
  enum status: {
    enrolled: 0,
    active: 1,
    completed: 2,
    dropped: 3
  }
  
  # Scopes
  scope :active_students, -> { where(status: 'active') }
  scope :completed, -> { where(status: 'completed') }
  
  # 計算進度的方法
  def update_progress!
    total_lessons = course.lessons.count
    completed_lessons = user.lesson_completions
                            .joins(:lesson)
                            .where(lessons: { chapter_id: course.chapters.pluck(:id) })
                            .count
    
    self.progress_percentage = (completed_lessons.to_f / total_lessons * 100).round
    save!
  end
end

# app/models/chapter.rb
class Chapter < ApplicationRecord
  belongs_to :course
  has_many :lessons, -> { order(:position) }, dependent: :destroy
  
  # 多型評論(章節也可以被評論)
  has_many :comments, as: :commentable, dependent: :destroy
  
  # 驗證
  validates :title, presence: true
  validates :position, presence: true, 
    uniqueness: { scope: :course_id }
  
  # 自動設定位置
  before_validation :set_position, on: :create
  
  private
  
  def set_position
    self.position ||= course.chapters.maximum(:position).to_i + 1
  end
end

# app/models/lesson.rb
class Lesson < ApplicationRecord
  belongs_to :chapter
  
  # 委派給 chapter 來取得 course
  # 這樣可以直接呼叫 lesson.course
  delegate :course, to: :chapter
  
  # 課時完成記錄
  has_many :lesson_completions, dependent: :destroy
  has_many :completed_by_users, through: :lesson_completions, source: :user
  
  # 多型評論
  has_many :comments, as: :commentable, dependent: :destroy
  
  # 內容類型:video, text, quiz
  enum content_type: {
    video: 0,
    text: 1,
    quiz: 2
  }
  
  # 驗證
  validates :title, presence: true
  validates :position, presence: true,
    uniqueness: { scope: :chapter_id }
  validates :content_type, presence: true
  
  # Scopes
  scope :videos, -> { where(content_type: 'video') }
  scope :published, -> { where(published: true) }
  
  # 排序相關
  before_validation :set_position, on: :create
  
  private
  
  def set_position
    self.position ||= chapter.lessons.maximum(:position).to_i + 1
  end
end

# app/models/comment.rb
class Comment < ApplicationRecord
  # 多型關聯:評論可以屬於任何 commentable 的物件
  belongs_to :commentable, polymorphic: true
  belongs_to :user
  
  # 巢狀評論結構
  # optional: true 允許 parent_id 為 nil(根評論)
  belongs_to :parent, class_name: 'Comment', optional: true
  has_many :replies, class_name: 'Comment', 
           foreign_key: 'parent_id',
           dependent: :destroy
  
  # 驗證
  validates :content, presence: true
  validate :validate_reply_depth
  
  # Scopes
  scope :root_comments, -> { where(parent_id: nil) }
  scope :recent, -> { order(created_at: :desc) }
  
  # 防止評論巢狀太深
  def validate_reply_depth
    if parent && parent.depth >= 2
      errors.add(:parent, "回覆層級不能超過兩層")
    end
  end
  
  # 計算評論深度
  def depth
    parent ? parent.depth + 1 : 0
  end
  
  # 取得評論的完整上下文
  def full_context
    case commentable
    when Course
      "在課程《#{commentable.title}》的評論"
    when Lesson
      "在課時《#{commentable.title}》的評論"
    when Assignment
      "在作業《#{commentable.title}》的評論"
    else
      "評論"
    end
  end
end

# app/models/assignment.rb
class Assignment < ApplicationRecord
  belongs_to :course
  has_many :submissions, dependent: :destroy
  has_many :comments, as: :commentable, dependent: :destroy
  
  validates :title, presence: true
  validates :due_date, presence: true
  
  scope :upcoming, -> { where('due_date > ?', Time.current).order(:due_date) }
  scope :past_due, -> { where('due_date < ?', Time.current) }
end

# app/models/submission.rb
class Submission < ApplicationRecord
  belongs_to :assignment
  belongs_to :user
  
  # 透過 assignment 取得 course
  delegate :course, to: :assignment
  
  validates :user_id, uniqueness: { scope: :assignment_id,
    message: "已經提交過這份作業" }
  
  enum status: {
    draft: 0,
    submitted: 1,
    graded: 2,
    returned: 3
  }
  
  scope :pending_grading, -> { where(status: 'submitted') }
end

# app/models/lesson_completion.rb
class LessonCompletion < ApplicationRecord
  belongs_to :user
  belongs_to :lesson
  
  validates :user_id, uniqueness: { scope: :lesson_id }
  
  # 完成課時後自動更新註冊進度
  after_create :update_enrollment_progress
  
  private
  
  def update_enrollment_progress
    enrollment = user.enrollments.find_by(course: lesson.course)
    enrollment&.update_progress!
  end
end

關鍵設計決策說明:

  1. 為什麼選擇角色系統而非 STI? 因為在 LMS 中,同一個使用者可能在不同課程扮演不同角色。使用角色系統提供了更大的靈活性。

  2. 為什麼 Enrollment 是獨立模型? Enrollment 不只記錄關聯,還包含進度、成績、狀態等重要業務資訊。這是 Rails 「中間表是一等公民」理念的體現。

  3. 為什麼使用多型關聯實作評論? 這讓評論系統可以輕易擴展到新的實體,而不需要修改評論模型本身。

  4. 為什麼使用 delegate? 這讓我們可以寫 lesson.course 而不是 lesson.chapter.course,提升程式碼可讀性。

進階挑戰:查詢優化(1 小時)

挑戰目標: 實作一個智慧的課程推薦服務,學習如何在保持查詢效率的同時處理複雜的業務邏輯。這個練習會讓你深入理解如何避免 N+1 查詢,以及如何設計高效的資料庫查詢。

完整解答與詳細說明:

# app/services/course_recommendation_service.rb
class CourseRecommendationService
  def self.recommend_for_user(user, limit: 10)
    # 第一步:收集使用者的學習資料
    # 使用 includes 預載入關聯,避免 N+1 查詢
    enrolled_course_ids = user.enrollments.pluck(:course_id)
    
    # 找出使用者已完成或正在學習的課程類別
    # 使用 joins 而非 includes,因為我們只需要類別 ID
    user_categories = Category
      .joins(:courses)
      .where(courses: { id: enrolled_course_ids })
      .distinct
      .pluck(:id)
    
    # 第二步:找出使用者完成課程的難度等級
    # 這幫助我們推薦適當難度的課程
    completed_levels = user.enrollments
      .completed
      .joins(:course)
      .pluck('courses.difficulty_level')
      .uniq
    
    # 計算推薦的難度範圍
    max_completed_level = completed_levels.max || 0
    recommended_levels = (max_completed_level..(max_completed_level + 1))
    
    # 第三步:建構主查詢
    # 使用子查詢來計算課程的熱門度分數
    courses = Course
      .where.not(id: enrolled_course_ids)  # 排除已註冊的課程
      .where(category_id: user_categories)  # 相同類別
      .where(difficulty_level: recommended_levels)  # 適當難度
      .where(published: true)  # 只推薦已發布的課程
      
    # 第四步:加入熱門度和評分的計算
    # 使用 left_joins 確保即使沒有註冊或評價的課程也會被包含
    courses = courses
      .left_joins(:enrollments, :reviews)
      .select(
        'courses.*',
        'COUNT(DISTINCT enrollments.id) as enrollment_count',
        'AVG(reviews.rating) as average_rating',
        # 計算綜合分數:70% 基於註冊數,30% 基於評分
        '(COUNT(DISTINCT enrollments.id) * 0.7 + 
          COALESCE(AVG(reviews.rating), 3) * 10 * 0.3) as popularity_score'
      )
      .group('courses.id')
      
    # 第五步:加入個人化因素
    # 檢查使用者的學習偏好(如果有記錄的話)
    if user.learning_preferences.present?
      courses = apply_user_preferences(courses, user)
    end
    
    # 第六步:加入協同過濾
    # 找出相似使用者也學習的課程
    similar_user_course_ids = find_similar_users_courses(user, enrolled_course_ids)
    
    # 使用 CASE WHEN 給相似使用者的課程加權
    courses = courses.select(
      "CASE 
        WHEN courses.id IN (#{similar_user_course_ids.join(',').presence || 'NULL'}) 
        THEN 1.2 
        ELSE 1.0 
      END as similarity_boost"
    )
    
    # 第七步:最終排序和限制
    courses
      .having('COUNT(DISTINCT enrollments.id) > ?', 5)  # 至少要有 5 個人註冊
      .order('popularity_score * similarity_boost DESC')
      .limit(limit)
      .includes(:instructor, :category, :tags)  # 預載入展示所需的關聯
  end
  
  private
  
  # 應用使用者偏好設定
  def self.apply_user_preferences(courses, user)
    preferences = user.learning_preferences
    
    # 根據偏好的學習時長過濾
    if preferences['preferred_duration'].present?
      case preferences['preferred_duration']
      when 'short'
        courses = courses.where('duration_hours <= ?', 10)
      when 'medium'
        courses = courses.where(duration_hours: 10..30)
      when 'long'
        courses = courses.where('duration_hours > ?', 30)
      end
    end
    
    # 根據偏好的內容類型加權
    if preferences['content_type'].present?
      preferred_type = preferences['content_type']
      courses = courses.select(
        "CASE 
          WHEN courses.primary_content_type = '#{preferred_type}' 
          THEN 1.3 
          ELSE 1.0 
        END as content_preference_boost"
      )
    end
    
    courses
  end
  
  # 使用協同過濾找出相似使用者的課程
  def self.find_similar_users_courses(user, user_course_ids)
    return [] if user_course_ids.empty?
    
    # 找出也註冊了使用者課程的其他使用者
    # 並計算相似度(基於共同課程數)
    similar_users = User
      .joins(:enrollments)
      .where(enrollments: { course_id: user_course_ids })
      .where.not(id: user.id)
      .group('users.id')
      .having('COUNT(enrollments.course_id) >= ?', 2)  # 至少有 2 門共同課程
      .order('COUNT(enrollments.course_id) DESC')
      .limit(20)
      .pluck(:id)
    
    return [] if similar_users.empty?
    
    # 找出這些相似使用者註冊但目標使用者還沒註冊的課程
    Course
      .joins(:enrollments)
      .where(enrollments: { user_id: similar_users })
      .where.not(id: user_course_ids)
      .group('courses.id')
      .having('COUNT(DISTINCT enrollments.user_id) >= ?', 3)  # 至少 3 個相似使用者註冊
      .pluck(:id)
  end
end

# 進階版本:加入快取和批次處理
class OptimizedCourseRecommendationService < CourseRecommendationService
  CACHE_EXPIRY = 1.hour
  
  def self.recommend_for_user(user, limit: 10)
    # 使用快取避免重複計算
    cache_key = "recommendations/user_#{user.id}/limit_#{limit}"
    
    Rails.cache.fetch(cache_key, expires_in: CACHE_EXPIRY) do
      recommendations = super(user, limit)
      
      # 預先計算和快取一些昂貴的資料
      preload_recommendation_data(recommendations)
      
      recommendations
    end
  end
  
  private
  
  def self.preload_recommendation_data(courses)
    # 批次載入所有需要的關聯資料
    ActiveRecord::Associations::Preloader.new(
      records: courses,
      associations: [
        :instructor,
        :category,
        :tags,
        { chapters: :lessons },
        { enrollments: :user }
      ]
    ).call
    
    # 批次計算統計資料
    course_ids = courses.map(&:id)
    
    # 一次查詢取得所有課程的完成率
    completion_rates = Enrollment
      .where(course_id: course_ids, status: 'completed')
      .group(:course_id)
      .count
    
    # 一次查詢取得所有課程的平均學習時間
    avg_completion_times = Enrollment
      .where(course_id: course_ids, status: 'completed')
      .group(:course_id)
      .average('completed_at - created_at')
    
    # 將統計資料附加到課程物件
    courses.each do |course|
      course.instance_variable_set(:@completion_rate, 
        completion_rates[course.id].to_f / course.enrollments.count)
      course.instance_variable_set(:@avg_completion_time, 
        avg_completion_times[course.id])
    end
    
    courses
  end
end

# 使用範例
user = User.find(1)
recommendations = OptimizedCourseRecommendationService.recommend_for_user(user)

# 顯示推薦結果
recommendations.each do |course|
  puts "推薦課程:#{course.title}"
  puts "  講師:#{course.instructor.name}"
  puts "  類別:#{course.category.name}"
  puts "  難度:#{course.difficulty_level}"
  puts "  註冊人數:#{course.enrollment_count}"
  puts "  平均評分:#{course.average_rating&.round(2) || 'N/A'}"
  puts "  推薦分數:#{course.popularity_score.round(2)}"
  puts "---"
end

關鍵優化技巧說明:

  1. 避免 N+1 查詢的策略:

    • 使用 includes 預載入需要顯示的關聯資料
    • 使用 joins 進行過濾(不需要載入完整物件)
    • 使用 pluck 只取需要的欄位
  2. 使用資料庫進行計算:

    • 在 SELECT 中使用聚合函數(COUNT、AVG)
    • 使用 CASE WHEN 進行條件計算
    • 讓資料庫處理排序和過濾
  3. 批次處理的重要性:

    • 一次查詢多筆資料,而非逐筆查詢
    • 使用 where(id: ids) 而非多次 find
    • 預先計算並快取昂貴的運算結果
  4. 快取策略:

    • 對計算成本高的推薦結果進行快取
    • 設定合理的過期時間
    • 在使用者行為改變時清除快取
  5. 協同過濾的實作:

    • 基於共同課程找出相似使用者
    • 推薦相似使用者也在學習的課程
    • 使用門檻值確保推薦品質

效能比較:

# 測試效能差異
require 'benchmark'

user = User.find(1)

Benchmark.bm do |x|
  x.report("未優化版本:") do
    # 簡單的實作,會產生大量查詢
    courses = Course.where.not(id: user.enrolled_courses.pluck(:id))
    courses.each do |course|
      course.enrollments.count  # N+1 查詢
      course.reviews.average(:rating)  # 又一個 N+1
    end
  end
  
  x.report("優化版本:") do
    CourseRecommendationService.recommend_for_user(user)
  end
  
  x.report("快取版本:") do
    OptimizedCourseRecommendationService.recommend_for_user(user)
  end
end

透過這些練習,你不只學會了如何使用 Rails 的關聯功能,更重要的是理解了如何設計高效、可維護的資料查詢層。這些技能在建構真實的 LMS 系統時將會非常重要。

知識連結:螺旋深化的第二圈

我們在 Day 4 初次接觸 ActiveRecord,學習了基本的 CRUD 操作。今天我們深入到關聯的設計哲學,理解了 Rails 如何將業務關係轉化為優雅的程式碼。這些知識將在以下場景繼續深化:

  • Day 20(資料庫進階):我們會探討分片、讀寫分離等大規模系統的資料架構
  • Day 22-23(LMS 核心功能):今天學習的關聯設計將成為系統的骨架
  • Day 25(推薦系統):複雜查詢技巧將用於實作推薦演算法

特別要注意的是,今天學習的查詢優化技巧不只是為了效能,更是為了保持程式碼的可維護性。當你的 LMS 系統成長到數萬使用者、數千課程時,這些優化將成為系統能否順利運行的關鍵。

總結:關聯不只是技術,更是思維

完成今天的學習後,我們獲得了三個層次的成長:

知識層面,我們學會了 has_many :through 的靈活運用、多型關聯的設計模式、N+1 查詢的識別與解決。這些技術知識是建構複雜系統的基礎工具。

思維層面,我們理解了 Rails 將中間表視為業務實體的設計哲學、關聯作為雙向契約的概念、查詢優化與程式碼可讀性的平衡藝術。這種思維方式會改變你設計系統的方式。

實踐層面,我們能夠設計 LMS 系統的複雜資料關係、優化大規模查詢的效能、使用工具持續監控和改進查詢效率。這些能力讓你能應對真實世界的挑戰。

自我檢核清單

完成今天的學習後,你應該能夠:

  • [ ] 解釋 has_many :throughhas_and_belongs_to_many 的差異與選擇時機
  • [ ] 實作多型關聯並理解其適用場景
  • [ ] 識別並解決 N+1 查詢問題
  • [ ] 區分 includes、preload、eager_load 的使用場景
  • [ ] 設計 LMS 系統中的複雜關聯結構
  • [ ] 使用 Bullet 等工具監控查詢效能

延伸資源

深入閱讀:

相關 Gem:

  • bullet:N+1 查詢偵測工具
  • active_record_doctor:資料庫健康檢查工具
  • prosopite:另一個 N+1 偵測工具,更輕量級

明日預告

明天我們將探討認證系統的實作。如果說今天學習的是如何優雅地表達資料關係,那明天就是如何安全地保護這些資料。我們會從零開始實作 JWT 認證系統,理解 token 的生命週期管理,探討無狀態認證的優勢與挑戰。

準備好了嗎?讓我們繼續這段深入 Rails 核心的旅程。


上一篇
Day 7: 模型層設計與業務邏輯 - 讓程式碼說出業務的語言
下一篇
Day 9: 認證系統實作 - 從零打造 JWT 認證的完整旅程
系列文
30 天 Rails 新手村:從工作專案學會 Ruby on Rails13
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言